Skip to content

S1: Split into a crate workspace and expose a Rust client API - #96

Open
passcod wants to merge 60 commits into
mainfrom
workhorse/s1
Open

S1: Split into a crate workspace and expose a Rust client API#96
passcod wants to merge 60 commits into
mainfrom
workhorse/s1

Conversation

@passcod

@passcod passcod commented Aug 24, 2026

Copy link
Copy Markdown
Owner

Restructures the single faith cdylib into a Cargo workspace: a browser-shaped Rust client crate web-faith, six standalone component crates split out beneath it (alt-svc, connection tracking, cookies, DNS, encoding, integrity), and a thin web-faith-napi binding crate that continues to ship as @passcod/faith.

  • Moves existing internals (agent, fetch, options, error handling, etc.) into crates/web-faith-napi, leaving the napi-specific glue there and pulling reusable, system-independent pieces out into their own crates.
  • Adds a new web-faith crate as the entry point for the high-level Rust client, along with specs and a design mockup proposing its Agent/fetch/Request/Response API shape, built on http/url ecosystem types.
  • Updates the publish workflow and workspace Cargo.toml/Cargo.lock to build the napi binding from its new crate location (-p web-faith-napi).

This PR lands the workspace restructure and proposed Rust API design; it does not yet publish to crates.io.

passcod and others added 30 commits August 18, 2026 10:33
Move the crate to crates/web-faith-napi and turn the root manifest into a
workspace, so the component crates and the Rust client have somewhere to land.
Shared package metadata (version, edition, MSRV 1.96, licence, repository,
authors) and the dependency versions move to the root for the crates to inherit.

The lib keeps the name `faith` so the built artifact stays libfaith.so, which
the release workflow's cross-compile steps copy by name.

build.rs walked to "Cargo.lock" relative to the crate; under a workspace the
lock file sits at the root, so it now searches the ancestor directories.

The benchmark HTTP/3 server is excluded rather than made a member: it keeps its
own lockfile on purpose so the quinn/h3 stack stays out of this graph.

No behaviour change; the generated index.js and index.d.ts are unchanged.
FaithError and FaithErrorKind were defined on a napi string_enum and carried
napi conversions, so every module that reported an error was tied to a
JavaScript runtime. The kinds and the error now live in web-faith, which builds
and documents with no napi in its dependency graph, and the Node surface keeps
what is genuinely its own: the mapping from a kind to a JavaScript error class,
and turning an error into a value V8 can carry.

The napi conversions arrive as an extension trait rather than inherent methods,
the error no longer being this crate's type to add methods to.

errorCodes() reads the codes from the kinds themselves, so the exported
ERROR_CODES map still cannot drift from the errors.

This drops FaithErrorKind from the native binding: it was emitted only because
the enum carried napi's attribute, and the package's export map admits nothing
but the wrapper, so no caller could reach it. The documented surface is
wrapper.js's ERROR_CODES, which is unchanged, still holds all 22 codes, and is
still checked against errorCodes() by test/error-codes.test.js.
Subresource Integrity moves to a crate of its own, which builds, tests, and
documents with no JavaScript runtime in its graph and speaks only ssri.

It names its own error for the two failures it can produce, and web-faith
converts them at the boundary, so a caller still matches on one error type and
the codes reported for a bad value and a mismatch are unchanged.

The client gains an `integrity` feature, on by default, which drops the
dependency when turned off. The nine tests move with the code.
Content coding moves to a crate of its own. It reached into the client for the
body-stream type; it now names that shape itself, so the dependency no longer
points upward, and it takes header types from http rather than through reqwest,
which drops reqwest from its graph entirely.

Nothing about the codings, the Accept-Encoding default, or the decode decision
changes. The twenty tests move with the code.
The jar moves to a crate of its own. It took reqwest's Url and HeaderValue,
which are the url and http types under another name, so it now names them
directly, and the reqwest CookieStore impl sits behind a `reqwest` feature.

That impl held the only way to store a response's cookies or read the header to
send, so both are now inherent methods and the trait delegates to them: a caller
without reqwest gets a jar that works rather than one it cannot drive. The
crate's own tests exercise those methods, so they pass with no reqwest in the
graph at all.

The storage, matching, and eviction rules are unchanged.
The resolver moves to a crate of its own: the cache, the discovery ladder, the
HTTPS record query, and Happy Eyeballs. It had no ties to the rest of the code
at all, so the only coupling to break was reqwest's Resolve impl, which now sits
behind a `reqwest` feature the way the jar's does.

The resolver itself, its transports, and the server order are unchanged.
Reading the operating system's per-connection statistics moves to a crate of its
own, along with the Linux, macOS, and Windows implementations behind it.

This was the one component holding napi types: the view it returned was a napi
object with JavaScript Date fields, so the tracker could not be compiled away
from a JS runtime. It now reports a snapshot of plain SystemTime and integer
values, and the napi crate turns that into the object `connections()` returns.

The JavaScript surface is unchanged, Dates and all.
The Alt-Svc store and the HTTP/3 upgrade machinery move to a crate of its own,
which draws on web-faith-dns for the resolution its probes need.

It read the client's timing stamp out of the request extensions, so it needed a
type from the layer above. It now names what it wants of that type as an
ArrivalStamp trait and the middleware is generic over it: the stamp stays the
client's, which is what reads it back to surface the timing, and this layer keeps
being the one place a response's arrival is observed.

The crate is pulled in by the http3 feature, so a build without HTTP/3 does not
carry it. Both feature configurations build clean.

This is the last of the six components; every one of them now compiles and tests
with no JavaScript runtime in its graph.
The first of the client's own code to leave the binding crate. The body holder
and its streams, the timing slot and what it measures, and the two retry layers
in the request path were already free of napi, so they move as they are.

Timing splits: the measuring is the client's, while the shape JavaScript receives
it in stays behind as a napi object built from it.

The client gains the http3 feature, since the stamp it owns is marked by the
Alt-Svc layer, and the binding's own http3 feature now turns the client's on with
it. Both feature configurations build clean.
The recipe that builds the agent's reqwest clients, the Node environment
variables it layers on, the flow-control windows, and the HTTP cache store move
to the client crate. This code was already free of napi by design: the recipe
exists because a client has to be buildable more than once for a network change,
and AgentOptions could not serve because it carries values belonging to the JS
call that passed them.

The redirect choice becomes the client's own RedirectPolicy. It carries no
`manual` variant, that having never differed from `follow` here, and the Node
surface's enum maps onto it, so JavaScript still takes every value it did.

web-faith is now depended on with default features off, so the binding's http3
feature turns the client's on rather than the two drifting apart. Both
configurations build clean and warning-free.
Spec references move out of doc comments and into normal comments beside the
item they belong to: a spec id is traceability for this repository, not
documentation for someone reading the crate on docs.rs. No reference is lost.

The crate-level docs said why Faith needed each piece factored out, which is the
wrong subject now that each is published on its own. They say what the crate is
for and how to drive it instead. The cookie jar leads with the rules it keeps
that a browser-shaped model does not, and carries a worked example; the resolver
leads with the warmable cache; the Alt-Svc crate had no crate documentation at
all, having been a module, and now describes the upgrade decision it exists to
make.

Making the components public also exposed several doc links to items that stayed
private, which would have shipped as broken pages. Two are fixed by publishing
what the prose already treated as public: the path-time floor a documented factor
is measured against, and reading a content coding off the wire. The rest name the
behaviour rather than the private item.

`cargo doc --workspace --no-deps` is now warning-free. One line of index.d.ts
changes with it: napi emits doc comments into TypeScript, where Rust's intra-doc
link syntax was only ever noise.
The Rust surface's variables were gathered into a section of their own, which
meant reading a variable's semantics in one place and whether it applied to you
in another. Each section now opens by naming the surfaces it applies to, and the
two that carry a mix say where the line falls: both surfaces read the OpenSSL
trust-store and proxy variables, while the NODE_-prefixed ones are the Node
surface's alone.

Nothing about what any variable does changes.
…spell them

The compatibility stance mixed the stance itself with Node API detail: the shapes
fetch accepts, the browser-assuming options that are ignored, and the extensions
that are additive were each spelled out here as well as at their own site. All
three are already specified in REQ and in the response specs, so what is left is
the stance a reader comes to this file for.

The claim that these specs describe behaviour in JavaScript terms is gone too. It
licensed the wrong thing: a spec covering both surfaces should name the concept
and link to where it is defined, and one that covers a single surface says so.

Following that, the environment spec now names the agent's extra roots rather
than spelling the Node option, which is defined in TLS either way.
passcod and others added 30 commits August 24, 2026 15:19
Warming a name and warming an origin both take their argument loosely: a host
with a scheme or a port it does not need, a URL with a path and userinfo that
play no part in the origin. The parsing that decides what was meant is pure, so
it moves to the client with its eight tests, leaving the verbs to act on the
result.

The origin key here and the one the Alt-Svc cache derives are the same format
arrived at twice; worth merging when the agent itself moves.
The running counters are the agent's, and a response holds a share of them
because a body finishing is what settles two of the four. Both surfaces report
them, so they move to the client, which reads them as the u64 they are counted
in.

The Node surface keeps its own shape, converting from that reading: JavaScript
takes them as i64, and a count past that saturates rather than wrapping, exactly
as before.
The agent moves to web-faith. What stays behind is the Node surface's own work:
validating whatever a caller passed as AgentOptions into a recipe and settings,
and a napi class holding a handle on the client's agent.

The verbs are the client's now. close and network_changed were pure already and
move as they were. The cookie verbs take a parsed URL rather than a string, since
parsing what a caller wrote belongs to the surface that took it. prefetch_dns and
preconnect return a future rather than reaching for a JS promise: a warm-up that
cannot be attempted is refused before the future exists, which is what lets the
binding throw synchronously for a caller's mistake while a network failure stays
quiet.

The client's agent also carries the settings a request consults, so the split is
between what builds a client and what each request reads.

The Node surface is unchanged. index.d.ts loses only the spec markers that had
been leaking out of doc comments into the published TypeScript; every line of
documentation a caller reads is intact.
Where trailers land, what is known of the peer, and opening a destination for a
body write are all the response's own business rather than JavaScript's, so they
move to the client with the two tests that guard the trailers wait against
spinning.

The file destination becomes the client's own shape, with the defaulting the JS
options object leaves implicit made explicit; the napi object converts into it.
web_faith::response::Response holds the response's state and the reads over it:
disturbing the body, gathering it, and writing it out to a file. The napi class
is a handle on one, and each JS method delegates.

Writing to a file takes a progress closure rather than a threadsafe function, and
reports in the counts it measures; the binding hands it a closure that crosses
into JavaScript, and converts the report and the result into the shapes JS takes.
The file destination's defaults, which the options object left implicit, are
explicit in the client's own type.

The integrity dependency stops being optional. The client's read path verifies
unconditionally, and gating the call sites would have meant a build that quietly
skips verification -- where the spec asks for a feature to remove the API that
offers it instead. That belongs with the request API in step 9, so the feature
comes back then rather than standing as a trap now.

index.d.ts loses one more spec marker that had been leaking into the published
TypeScript. Nothing else about the Node surface changes.
Sending a request and building the response that comes back is the client's, so
it moves: web_faith::request::send takes an agent, a URL, the options, a body,
and an optional abort future. The Node surface reads a fetch() call into those
and wraps the result.

The body arrives as the client's own shape rather than a napi buffer and a shared
receiver: bytes for one already in hand, a stream for one arriving in chunks. The
binding still takes the receiver out of its lock before anything can refuse the
request, so a refusal drops the stream and whatever is feeding it stops.
Cancellation is a future the caller supplies, rather than napi's signal reaching
into the send.

`same-origin` credentials resolve to `include` at the boundary, that distinction
needing an origin the client does not have.
The client's crate documentation now says what it holds, and two doc links that
broke when the code moved are fixed, so `cargo doc --workspace` is clean again.

Step 8 is done: web-faith holds the agent, the request path, the response and its
reads, the body, timing, and retry machinery, and the client recipe.
web-faith-napi is the binding it always should have been -- JS option shapes,
AgentOptions validation, the napi classes wrapping the client's types, and napi's
own promise, stream, and threadsafe-function machinery.
The counters' listing goes: it spelled the fields in the Node surface's casing,
and the type it returns already documents each one with its Rust name and its
type, which no prose list can better.

AgentStats is non-exhaustive. What an agent counts can grow, and adding a counter
should not be a breaking change for anyone reading them.

The rest is what the moves carried in and I left: spec references sitting in doc
comments rather than beside the item, and JavaScript option paths naming settings
the client holds under its own names. The agent's fields now say what they govern
rather than which JS option they mirror.
There is no good reason for a caller to turn digest verification off, and the
component is small enough that leaving it out saves nothing worth measuring. What
a feature would buy is a build that quietly skips a check the caller asked for.

So integrity has no feature, and the specs say why: RUST no longer claims every
component has one, and RSAPI no longer promises that `integrity()` disappears.
…feature

Subresource Integrity is a module of `web-faith` rather than a crate beneath it,
and its functions report the client's own error, so the boundary conversion goes
with the boundary. The nine tests come along.

The specs had been treating component, crate, and feature as one thing: RUST
promised a feature per component crate, and RSAPI promised the API a component
gated. They are three axes that need not line up. A feature now names a
capability, may or may not drop a dependency when turned off, and a subsystem the
client is not built without carries none at all.

The reasoning about why integrity is always built goes with it. A spec says what
the system is, and the system is one where SRI is part of the client.
The default `User-Agent` names the client and the version of reqwest a request
actually goes out on, so the client is what should compose it. web-faith gains a
build script to read that version from the lock file, and USER_AGENT with it.

The Node surface's constant now reads from the client's, so the value on the wire
has one source. It is unchanged: `Faith/0.7.0 reqwest/0.13.4`.

RSAPI has the Rust surface export the string but not the versions inside it, a
Rust caller having its own package metadata to read those from, so only
USER_AGENT is public here.
A Rust caller reads a response through the response rather than through the
binding: status, status_text, ok, headers, url, redirected, version, peer, and
body_used as accessors, and bytes, text, json, body_stream, discard, and the file
write as the reads over the body, which is the set RSAPI names.

json is generic over what it deserialises into, as a Rust caller expects, rather
than handing back a parsed document. body_stream reports the chunk error as the
client's own error instead of the string the pipeline carries internally, and can
be called more than once: each call hands back the same shared stream.

The napi methods now delegate to these, so the reading rules -- a second read
fails, integrity is verified once the body is in hand, discarding settles the
trailers -- are stated once.
The body implements http_body::Body over the chunks the response already streams,
reporting the client's own error, and a response converts into an
http::Response carrying its status, version, and headers. That is what lets a
Faith response reach code written against the wider ecosystem without a shim.

The conversion is fallible rather than infallible: taking the body can find it
already being consumed, and a caller should hear that rather than get a body that
yields nothing. A response that cannot carry a body converts to an empty one.

The test asserts what a consumer observes -- draining yields no bytes -- after a
first attempt asserted a size hint this does not implement, which the default
hint is entitled not to give.
The ~440 lines that turn options into the recipe an agent's clients are built
from move to web-faith, along with the option groups they read, the IPv6
wildcard probe, and the flow-control window reconciliation with its tests. The
binding maps its JavaScript options object onto the client's shapes and calls
from_options.

Moved rather than reimplemented on purpose: both surfaces have to land on the
same defaults, and a second implementation is how they drift. What the binding
still owns is the JavaScript vocabulary -- a union for cookies, either spelling
of a PEM, strings where Rust has enums -- and entering a tokio runtime, which it
needs because a napi callback can run outside one.

Agent::new() follows, so a Rust caller no longer has to assemble a recipe by
hand.
The builder's methods mirror the option groups, and a group is reached through a
closure so one left alone is absent from the call rather than spelled out as
absent, which is the shape RSAPI asks for.

Setters take what a Rust caller holds rather than what the option carries:
Duration whichever unit the setting is stored in, IpAddr for the local address,
anything that converts into a String elsewhere. Milliseconds and seconds saturate
rather than wrap on a duration no setting could mean.

Tests cover both halves of the absent-rather-than-spelled-out rule: a group
reached but not set keeps its other settings unset, and a group never reached
stays None.
No source file past 1000 lines, tests-only files excepted. Each split
follows the internal seams: the dns crate into transport/resolver/
discovery/https/settings, alt-svc into cache/header/prober/middleware,
the client's agent into build/warm and its request path into
send/target/builder, and the binding's agent into options/convert.

Tests move with the code they exercise, as a child module of it, which
keeps their access to private items.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Each is default-on; turning one off drops the component crate, the
reqwest feature behind it, and the API that only means something with
it present. alt-svc gains a dns feature of its own for the HTTPS-record
sink, so the Alt-Svc store is usable without the resolver crate.

napi's derives do not honour cfg on an object field or an impl method,
so the binding removes methods through separate gated impl blocks and
refuses an option group the build cannot honour.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Also clears the binding's dead dependencies: it carried two dozen
crates from before the extraction, several of which a feature claimed
to drop while linking them anyway.

A slim binding refuses an option it cannot honour, at agent
construction for an option group and at fetch for a per-request one.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The http3 option group was accepted and ignored without the feature;
it now goes with it. reqwest's rustls feature moves out of the
workspace root into tls-aws-lc-rs, with tls-ring the alternative,
installing ring as the process crypto provider where it is the only
choice. Neither backend is a compile error.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant